<?php

/**
 * km\util\Validator class (Validator.php, Wed Jan 10 12:32:57 MSK 2007 12:32:57)
 * Simple validation of input data, based on filter extension for PHP.
 * Filter extension is enabled by default since php 5.2.0
 * 
 * Validator supports fluent interface ( http://www.martinfowler.com/bliki/FluentInterface.html )
 * 
 * example of usage:
 * $v = new Validator();
 * $v->addRequired('domain', 'Domain field is required')
 *		->addRule('domain', 
 *			array('filter' => FILTER_VALIDATE_URL, 'flags' => array(FILTER_FLAG_HOST_REQUIRED)), 
 *			'Domain must be a valid URL')
 *		->addRule('domain', 
 *			array('filter' => FILTER_CALLBACK, 'options' => array($this, 'filterUniqueDomain')), 
 *			'Domain name must be unique within the database')
 *		->addRule('enabled', FILTER_VALIDATE_BOOLEAN, '"Enabled" field must be a boolean value');
 *
 *	$vResult = $v->check($MY_INPUT_DATA);// $MY_INPUT_DATA is supposed to be an associative array (like $_POST or $_GET)
 *	var_dump($vResult->ok());
 *	var_dump($vResult->export());
 *	var_dump($vResult->getErrorList());
 * 
 * @package naf::util
 * @subpackage Validator
 */

namespace km\util;

use km\util\Validator_Result;
use \Km;

class Validator {
	
	/**#@+
	 * @var array
	 */
	protected $_rules = array();
	protected $_messages = array();
	protected $_required = array();
	protected $_equals = array();
	protected $_validated = array();
	/**#@-*/
	
	/**
	 * @var Validator[]
	 */
	private $conditionals = array();
	
	/**
	 * @var Validator_Result
	 */
	protected $_result;
	
	/**
	 * Constructor
	 */
	function __construct()
	{
		$this->_result = new Validator_Result();
	}
	/**
	 * Add validator to be executed ONLY when $test returns TRUE
	 *
	 * @param Validator $v
	 * @param Closure | callable $test
	 * @return Validator $this
	 */
	function addConditional(Validator $v, $test)
	{
		$this->conditionals[] = array($v, Km::toClosure($test));
		return $this;
	}
	
	/**
	 * Add required field
	 *
	 * @param string $key
	 * @param string $message
	 * @return Validator
	 */
	function addRequired($key, $message)
	{
		$this->_validated[$key] = null;
		$this->_required[$key] = $message;
		return $this;
	}
	
	/**
	 * Add equal fields. Useful for password confirmation.
	 *
	 * @param string $key1
	 * @param string $key2
	 * @param string $message
	 * @return Validator
	 */
	function addEquals($key1, $key2, $message)
	{
		$this->_equals[] = array($key1, $key2, $message);
		$this->_validated[$key1] = null;
		$this->_validated[$key2] = null;
		return $this;
	}
	
	/**
	 * Add rule
	 *
	 * @param string $key
	 * @param array | int filter @see filter_* functions ( http://php.net/filter )
	 * @param string $message
	 * @return Validator
	 */
	function addRule($key, $filter, $message)
	{
		$this->_validated[$key] = null;
		foreach ($this->_rules as $index => $stack)
			if (! array_key_exists($key, $stack))
				return $this->_doAddRule($key, $filter, $message, $index);
		
		return $this->_doAddRule($key, $filter, $message, null);
	}
	
	/**
	 * @param string $key
	 * @param string $message
	 * @param int $flags
	 * @return Validator
	 */
	function addStringRule($key, $message, $flags = null)
	{
		if (null === $flags)
		{
			$flags = FILTER_FLAG_NO_ENCODE_QUOTES;
		}
		
		return $this->addRule($key, array('filter' => FILTER_SANITIZE_STRING, 'flags' => $flags), $message);
	}
	
	/**
	 * @param string $key
	 * @param string $message
	 * @param int $flags
	 * @return Validator
	 */
	function addStringLengthRule($key, $message, $max, $min = 0, $flags = null)
	{
		$callback = function($s) use ($max, $min, $flags) {
			
			if (null === $flags)
			{
				$flags = FILTER_FLAG_NO_ENCODE_QUOTES;
			}
			
			if ($s = trim(filter_var($s, FILTER_SANITIZE_STRING, array('flags' => $flags)))) {
				
				$len = strlen($s);
				if (($len >= $min) && ($len <= $max)) {
					return $s;
				}
			}
			
			return false;
			
		};
		return $this->addCallbackRule($key, $callback, sprintf($message, $max, $min));
	}
	
	/**
	 * @param string $key
	 * @param string $message
	 * @param int $flags
	 * @return Validator
	 */
	function addRawStringRule($key, $message, $flags = null)
	{
		return $this->addRule($key, array('filter' => FILTER_UNSAFE_RAW, 'flags' => $flags), $message);
	}
	
	/**
	 * @param string $key
	 * @param string $message
	 * @param string $regexp
	 * @return Validator
	 */
	function addRegexpRule($key, $message, $regexp)
	{
		return $this->addRule($key, 
			array('filter' => FILTER_VALIDATE_REGEXP, 'options' => array('regexp' => $regexp)), 
			$message);
	}
	
	/**
	 * @param string $key
	 * @param string $message
	 * @return Validator
	 */
	function addEmailRule($key, $message)
	{
		return $this->addRule($key, FILTER_VALIDATE_EMAIL, $message);
	}

	/**
	 * @param string $key
	 * @param string $message
	 * @return Validator
	 */
	function addIpRule($key, $message)
	{
		return $this->addRule($key, FILTER_VALIDATE_IP, $message);
	}
	
	/**
	 * @param string $key
	 * @param string $message
	 * @return Validator
	 */
	function addUrlRule($key, $message)
	{
		return $this->addRule($key, FILTER_VALIDATE_URL, $message);
	}

	/**
	 * For ActiveRecord objects, validate a field for being unique
	 * @param string|string[] $key
	 * @param string $message
	 * @param ActiveRecord $activeRecord
	 * @return Validator
	 */
	function addUniqueRule($field, $message, $activeRecord)
	{
		if (is_array($field)) {
			if (! count($field)) {
				throw new Validator_Exception("Empty array passed as field parameter");
			}
			$key = reset($field);
			$fields = $field;
		} else {
			$key = $field;
			$fields = array($field);
		}
		return $this->addCallbackRule($key, function($value) use ($activeRecord, $key, $fields) {
			$select = $activeRecord->findAll()->ifWhere($activeRecord->id(), 'id != ?', $activeRecord->id());
			foreach ($fields as $field) {
				$fieldValue = $activeRecord->$field;
				if (null === $fieldValue) {
					// NULL values are allowed (if you want not-null validation, use addRequiredRule())
					return $value;
				}
				$select->where($field . ' = ?', $fieldValue);
			}
			return $select->count() ? false : $value;
		}, $message);
	}

	/**
	 * For ActiveRecord objects, validate a combination of fields for being unique
	 * @param string[] $fields
	 * @param string $message
	 * @param ActiveRecord $activeRecord
	 * @return Validator
	 */
	function addUniqueCombinationRule(array $fields, $message, $activeRecord)
	{
		return $this->addCallbackRule($key, function($value) use ($activeRecord, $key) {
			if (null === $value) {
				// NULL values are allowed (if you want not-null validation, use addRequiredRule())
				return null;
			}
			$select = call_user_func(array(get_class($activeRecord), 'findAll'))
				->where($key . ' = ?', $value)
				->ifWhere($activeRecord->id(), 'id != ?', $activeRecord->id());
			return $select->count() ? false : $value;
		}, $message);
	}
	
	/**
	 * @param string $key
	 * @param string $message
	 * @return Validator
	 */
	function addIntegerRule($key, $message, $options = null)
	{
		return $this->addRule($key, array('filter' => FILTER_VALIDATE_INT, 'options' => $options), $message);
	}
	
	/**
	 * @param string $key
	 * @param string $message
	 * @return Validator
	 */
	function addFloatRule($key, $message, $flags = null)
	{
		return $this->addRule($key, array('filter' => FILTER_VALIDATE_FLOAT, 'flags' => $flags), $message);
	}
	
	/**
	 * @param string $key
	 * @param string $message
	 * @return Validator
	 */
	function addBooleanRule($key, $message)
	{
		return $this->addRule($key, FILTER_VALIDATE_BOOLEAN, $message);
	}
	
	/**
	 * @param string $key
	 * @param callback $callback
	 * @param string $message
	 * @return Validator
	 */
	function addCallbackRule($key, $callback, $message)
	{
		return $this->addRule($key, array('filter' => FILTER_CALLBACK, 'options' => $callback), $message);
	}
	
	/**
	 * @param string $key
	 * @param callback $callback
	 * @param string $message
	 * @return Validator
	 */
	function addDateTimeRule($key, $message, $date_only = false)
	{
		return $this->addRule($key, array('filter' => FILTER_CALLBACK, 'options' => function($date) use ($date_only) {
			if (false !== ($ts = strtotime($date))) {
				return date('Y-m-d' . ($date_only ? '' : ' H:i:s'), $ts);
			} else {
				return false;
			}
		}), $message);
	}
	
	/**
	 * Perform validation check
	 *
	 * @param array $input
	 * @return Validator_Result
	 */
	function check($input)
	{
		array_walk_recursive($input, array($this, '_prepareInput'));
		$this->_result->reset();
		$this->_result->importRaw($input);
		
		foreach ($this->conditionals as $spec)
		{
			if ($spec[1]->__invoke($input))
			{
				$conditional_result = $spec[0]->check($input);
				$input = array_merge($input, $conditional_result->export());
				if (! $conditional_result->ok())
				{
					foreach ($conditional_result->getErrorList(true) as $key => $message)
					{
						$this->_result->addError($key, $message);
					}
				}
			}
		}

		$this->_failRequired($input) || $this->_failEquals($input) || $this->_failRules($input);
		
		return $this->_result;
	}
	
	/**
	 * @return Validator_Result
	 */
	final function result()
	{
		return $this->_result;
	}
	
	protected function _failRequired($input)
	{
		foreach ($this->_required as $key => $message)
			if (empty($input[$key]))
				$this->_result->addError($key, $message);
		
		return ! $this->_result->ok();
	}
	
	protected function _failEquals($input)
	{
		foreach ($this->_equals as $spec)
			if (@$input[$spec[0]] != @$input[$spec[1]])
				$this->_result->addError($spec[1], $spec[2]);
		
		return ! $this->_result->ok();
	}
	
	protected function _failRules($input)
	{
		$output = array();
		foreach ($input as $key => $value)
			if (! empty($value))
				$output[$key] = $value;

		foreach ($this->_rules as $index => $stack)
		{
			try {
				$tmp_output = filter_var_array($output, array_intersect_key($stack, $output));
			} catch (Validator_Exception $e) {
				$this->_result->addError(uniqid(), $e->getMessage());
			}

			foreach ($tmp_output as $key => $value)
			{
				if (empty($this->_rules[$index][$key])) continue;
				
				if (FILTER_VALIDATE_BOOLEAN == $this->_rules[$index][$key]['filter'])
				{
					$tmp_output[$key] = (bool) $value;
				}
				elseif (false === $value)
				{
					$this->_result->addError($key, $this->_messages[$index][$key]);
				}
			}
			
			$output = array_merge($output, $tmp_output);
		}

		$output = array_merge($input, $output);
		if ($ok = $this->_result->ok())
			$this->_result->import($output);
		
		return ! $ok;
	}
	
	protected function _prepareInput(& $value)
	{
		if (is_string($value)) $value = trim($value);
	}
	
	/**
	 * @return Validator
	 */
	protected function _doAddRule($key, $filter, $message, $index)
	{
		if (null === $index)
		{
			$index = count($this->_rules);
			$this->_rules[$index] = array();
			$this->_messages[$index] = array();
		}
		
		if (is_int($filter))
		{// make filters uniform
			$filter = array('filter' => $filter);
		}
		
		$this->_rules[$index][$key] = $filter;
		$this->_messages[$index][$key] = $message;
		return $this;
	}
}
